SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
8.0 KB · 155 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { GraphWorkbench } from '@/components/graph/graph-workbench';5import { defaultModeFor, GRAPH_MODES, graphSlugHref, isGraphMode } from '@/components/graph/modes';6import { BreadcrumbLd, Breadcrumbs } from '@/components/meta/breadcrumb-ld';7import { EntityBadge } from '@/components/ui/badges';8import { EntityLink } from '@/components/ui/entity';9import { Container, Note, PageHeader } from '@/components/ui/section';10import { EmptyState, Unavailable } from '@/components/ui/unavailable';11import { api, ApiError, apiD3, safe } from '@/lib/api';12import { fmtInt } from '@/lib/format';13import { predicateLabel, routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site';14import type { ExploreNode, GraphExploreMode, GraphExplorePayload } from '@/lib/types';1516type Params = { params: Promise<{ slug: string }>; searchParams: Promise<{ depth?: string; mode?: string }> };17const LIMIT = 150;1819const hrefFor = graphSlugHref;2021/** 404 → notFound(); any other failure → null (the page renders an Unavailable state). */22async function loadGraph(slug: string, mode: GraphExploreMode, depth: 1 | 2): Promise<GraphExplorePayload | null> {23  try {24    return await apiD3.graphExplore(slug, mode, depth, LIMIT);25  } catch (e) {26    if (e instanceof ApiError && e.notFound) notFound();27    return null;28  }29}3031export async function generateMetadata({ params, searchParams }: Params): Promise<Metadata> {32  const { slug } = await params;33  const sp = await searchParams;34  const d = await safe(api.entity(slug));35  if (!d) return { title: 'Graph', robots: { index: false } };36  const mode = isGraphMode(sp.mode) ? sp.mode : defaultModeFor(d.entity_type);37  const depth: 1 | 2 = sp.depth === '2' ? 2 : 1;38  const modeLabel = GRAPH_MODES.find((x) => x.mode === mode)?.label ?? 'Graph';39  const title = `${d.name} — ${modeLabel.toLowerCase()} graph`;40  const og = `${SITE_URL}/graph/og?node=${encodeURIComponent(d.slug)}&mode=${mode}&depth=${depth}`;41  return { title, description: `Everything AI Atlas links to ${d.name} (${typeLabel(d.entity_type).toLowerCase()}) in ${modeLabel.toLowerCase()} mode, with the predicate of each relation.`, alternates: { canonical: hrefFor(d.slug, mode, depth) }, openGraph: { title: `${title} | ${SITE_NAME}`, type: 'article', images: [{ url: og, width: 1200, height: 630 }] }, twitter: { card: 'summary_large_image', images: [og] } };42}4344export default async function GraphPage({ params, searchParams }: Params) {45  const { slug } = await params;46  const sp = await searchParams;47  const d = await safe(api.entity(slug));48  if (!d) {49    // distinguish "unknown slug" (404) from "API down"50    try {51      await api.entity(slug);52    } catch (e) {53      if (e instanceof ApiError && e.notFound) notFound();54    }55  }56  const mode = isGraphMode(sp.mode) ? sp.mode : defaultModeFor(d?.entity_type);57  const depth: 1 | 2 = sp.depth === '2' ? 2 : 1;58  const graph = await loadGraph(slug, mode, depth);59  const rootId = graph?.root ?? d?.id ?? '';60  const nodes = graph?.nodes ?? [];61  const edges = graph?.edges ?? [];62  const byId = new Map(nodes.map((n) => [n.id, n]));63  const groups = new Map<string, { node: ExploreNode; direction: 'out' | 'in' }[]>();64  for (const e of edges) {65    const isOut = e.source === rootId;66    const isIn = e.target === rootId;67    if (!isOut && !isIn) continue;68    const other = byId.get(isOut ? e.target : e.source);69    if (!other) continue;70    const key = `${e.predicate}|${isOut ? 'out' : 'in'}`;71    (groups.get(key) ?? groups.set(key, []).get(key)!).push({ node: other, direction: isOut ? 'out' : 'in' });72  }73  const modeDef = GRAPH_MODES.find((x) => x.mode === mode)!;74  const crumbs = [{ name: SITE_NAME, href: '/' }, { name: 'Knowledge graph', href: '/graph' }, ...(d ? [{ name: typeLabel(d.entity_type, true), href: routes.listing(d.entity_type) }, { name: d.name, href: routes.entity(d) }] : []), { name: modeDef.label, href: hrefFor(slug, mode, depth) }];7576  return (77    <>78      <BreadcrumbLd items={crumbs} />79      <Container wide>80        <Breadcrumbs items={crumbs} />81        <PageHeader82          eyebrow={<>Graph explorer {d && <EntityBadge type={d.entity_type} small />}</>}83          title={d ? <>{modeDef.label} around <Link href={routes.entity(d)} className="hover:text-accent">{d.name}</Link></> : `Around ${slug}`}84          lede={d ? `${modeDef.hint[0]?.toUpperCase()}${modeDef.hint.slice(1)}${depth === 2 ? ' — two hops' : ''}. Click a node to inspect it, double-click to expand, drag to pan, wheel to zoom.` : undefined}85          aside={86            graph ? (87              <p className="tnum text-xs text-ink-3">88                {fmtInt(graph.counts?.nodes ?? nodes.length)} nodes · {fmtInt(graph.counts?.edges ?? edges.length)} edges{graph.truncated ? <span className="text-warning"> · truncated at {LIMIT}</span> : ''}89              </p>90            ) : undefined91          }92          className="pb-3"93        >94          <ul className="no-scrollbar -mx-4 mt-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0" aria-label="Mode">95            {GRAPH_MODES.map((m) => (96              <li key={m.mode} className="shrink-0">97                <Link href={hrefFor(slug, m.mode, depth)} className={`inline-flex h-9 items-center border px-2.5 text-[12px] uppercase tracking-wide ${m.mode === mode ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink'}`} aria-current={m.mode === mode ? 'true' : undefined} title={m.hint}>98                  {m.label}99                </Link>100              </li>101            ))}102          </ul>103        </PageHeader>104      </Container>105      <div className="pb-16">106        {!graph || !d ? (107          <Container>108            <Unavailable what="Graph" />109          </Container>110        ) : nodes.length <= 1 || edges.length === 0 ? (111          <Container>112            <EmptyState title={`No ${modeDef.label.toLowerCase()} relations recorded for ${d.name}`}>113              Relations are written only when a source states them. Try another mode above, or <Link href={routes.entity(d)} className="link">go back to {d.name} →</Link>114            </EmptyState>115          </Container>116        ) : (117          <>118            <GraphWorkbench key={`${slug}:${mode}:${depth}`} initial={graph} root={{ slug: d.slug, name: d.name, entity_type: d.entity_type }} mode={mode} depth={depth} urlStyle="path" embedded />119            <Container wide>120              <section className="mt-8 min-w-0">121                <p className="eyebrow mb-2">Direct relations of {d.name}, as a list</p>122                {groups.size === 0 ? (123                  <p className="text-sm text-ink-3">No direct relations for the root node in this graph.</p>124                ) : (125                  <dl className="kv">126                    {[...groups.entries()].map(([key, items]) => {127                      const [pred, dir] = key.split('|') as [string, 'out' | 'in'];128                      return (129                        <div key={key}>130                          <dt>131                            {predicateLabel(pred, dir)} <span className="tnum text-ink-3">{fmtInt(items.length)}</span>132                          </dt>133                          <dd className="flex flex-wrap gap-x-3 gap-y-1">134                            {items.map(({ node }) => (135                              <span key={node.id} className="inline-flex items-center gap-1.5">136                                <EntityBadge type={node.entity_type} small />137                                <EntityLink e={node} />138                              </span>139                            ))}140                          </dd>141                        </div>142                      );143                    })}144                  </dl>145                )}146              </section>147              {graph.truncated && <Note className="mt-3">The neighbourhood is larger than {LIMIT} nodes; the API returned the first {LIMIT} and flagged the cut. Expand individual nodes, or use the entity's Relations block for full lists.</Note>}148            </Container>149          </>150        )}151      </div>152    </>153  );154}155